Splitwise is a popular expense-sharing application that helps groups of people such as roommates, friends, coworkers, or travelers split bills and keep track of who owes whom.
Overall Balance
+$330.00
Rather than requiring users to settle up after every expense, Splitwise allows them to log payments as they happen. It maintains a running balance for each user, tracking how much they owe or are owed.
In this chapter, we will explore the low-level design of a splitwise like service in detail.
Lets start by clarifying the requirements:
Before starting the design, it's important to ask thoughtful questions to uncover hidden assumptions and better define the scope of the system.
Here is an example of how a discussion between the candidate and the interviewer might unfold:
Candidate: Should the system support both one-to-one and group expenses?
Interviewer: Yes, we should support both. Users can create individual expenses as well as group expenses involving multiple participants.
Candidate: Should we support multiple amount split strategies (example: equal split, exact amount etc..)?
Interviewer: At a minimum, we should support equal, exact and percentage-based splits.
Candidate: Should the system simplify debts by offsetting mutual balances or minimizing transactions across the group?
Interviewer: Yes, we should implement a debt simplification feature that helps reduce the number of transactions required to settle up.
Candidate: Should users be able to settle up partially, or should settlements be allowed only in full?
Interviewer: Partial settlements should be supported. Users should be able to pay back in parts and see updated balances accordingly.
Candidate: Do we need to maintain a complete history of all expenses and settlements?
Interviewer: Yes, the system should keep a record of all expenses and payment activities so users can view their transaction history.
Candidate: Should we support multiple currencies or just assume a single currency for all transactions?
Interviewer: For now, let’s keep it simple and assume all transactions happen in a single currency.
After the requirements are clear, lets identify the core entities/objects we will have in our system.
Core entities are the fundamental building blocks of our system. We identify them by analyzing the functional requirements and highlighting the key nouns and responsibilities that naturally map to object-oriented abstractions such as classes, enums, or interfaces.
Let’s walk through the functional requirements and extract the relevant entities:
This indicates the need for a User entity to represent each participant and a Group entity to model collections of users involved in shared expenses. Each group will have members and a list of associated expenses.
To support this, we need a Split entity that represents an individual participant’s share in a given expense.
We also need an abstract SplitStrategy interface (or base class), with concrete implementations like EqualSplit, ExactSplit, and PercentageSplit. These strategies determine how the total expense amount is divided among participants.
This requires a BalanceSheet component (or mapping) that maintains user-to-user debts, updated every time an expense or settlement is recorded.
To support this, we need a Transaction or Payment entity to represent payments between users, including partial settlements. Each payment should reference the payer, payee, amount, and timestamp.
An Expense entity will represent each bill or transaction added to the system. It will include details such as description, amount, payer, date, involved users, and applied split strategy.
This suggests the need for a DebtSimplifier utility class or component that runs on the group’s balance sheet and optimizes who pays whom and how much.
User: Represents an individual participant. Maintains references to groups, transactions, and personal balances.Group: Represents a collection of users sharing multiple expenses. Contains group metadata and related transactions.Expense: Represents a single bill or cost incurred. Includes payer, participants, amount, and splitting strategy.Split: Represents an individual share of an expense, mapped to a user and amount.SplitStrategy (Interface): Defines how expenses are split. Examples include: EqualSplit, ExactAmountSplitBalanceSheet: Maintains all balances between users and exposes APIs to query and update dues.Transaction: Represents any financial operation (expense, payment, or settlement).DebtSimplifier: Utility that runs on the balance sheet to reduce the number of payments required to settle up within a group.These core entities define the fundamental abstractions of a Splitwise-like system and will guide the class design, interactions, and responsibilities in the low-level implementation.
This section details the classes that form the core of the Splitwise system, their responsibilities, and the relationships between them.
The system is designed around a set of core classes that model the real-world entities and concepts of expense sharing.
These classes are simple data containers with minimal logic, primarily used to store and transfer state.
UserRepresents a participant in the system.
It holds user details like id, name, and email. Each User is intrinsically linked to their personal BalanceSheet.
GroupRepresents a collection of Users who share expenses, such as for a trip or a household.
It contains a name and a list of members.
SplitA simple data object representing a single portion of an Expense.
It connects a User with the specific amount they owe for that expense.
TransactionRepresents a simplified, direct payment required to settle debts between two users, typically generated by the debt simplification logic.
It holds the from user, the to user, and the amount.
These classes contain the main business logic and orchestrate the application's functionality.
ExpenseModels a single spending event.
It's a complex object containing a description, total amount, who paidBy, and a list of Splits that define how the cost is distributed. It uses a SplitStrategy to calculate these splits.
ExpenseBuilderA nested builder class within Expense. It provides a fluent API to construct a complex Expense object step-by-step, accommodating different splitting methods and their required parameters.
BalanceSheetA crucial component that tracks a User's financial state relative to other users.
It maintains a map where each entry represents a net balance with another user. A positive value means the other user owes the owner, and a negative value means the owner owes the other user.
SplitwiseServiceActs as the central controller and entry point for the application.
It manages users and groups, orchestrates expense creation, and provides high-level functionalities like showing balances and simplifying debts.
The classes interact through a combination of composition and association, creating a cohesive system.
A strong "has-a" relationship where the child object's lifecycle is managed by the parent.
User and BalanceSheet: Each User has one BalanceSheet. The BalanceSheet is created when the User is instantiated and is fundamentally part of the User's state. User 1 -- 1 BalanceSheet.Expense and Split: An Expense has one or more Splits. The Split objects are created during the Expense's construction and have no meaning outside the context of that specific expense. Expense 1 -- 1..* Split.SplitwiseService and other objects: The SplitwiseService manages the lifecycle of all User and Group objects, acting as a container for them.A weaker "has-a" relationship where objects are related but can exist independently.
Group and User: A Group has multiple Users as members. Users can exist without being in a group and can be part of multiple groups. Group 1 -- * User.Expense and User: An Expense is associated with one User who paid (paidBy) and multiple Users who participated. Expense * -- 1 User.Expense and SplitStrategy: An Expense uses one SplitStrategy to perform its split calculation. The strategy is passed in during construction. Expense * -- 1 SplitStrategy.Split and User: A Split is associated with the User who owes that portion of the expense. Split * -- 1 User.Transaction and User: A Transaction connects two Users (a payer and a payee). Transaction * -- 2 User.An "is-a" relationship.
EqualSplitStrategy, ExactSplitStrategy, and PercentageSplitStrategy are all implementations of the SplitStrategy interface.The design leverages several well-known patterns to ensure flexibility, maintainability, and ease of use.
This pattern is used to handle the different ways an expense can be split. The SplitStrategy interface defines a common contract for all splitting algorithms. Concrete classes like EqualSplitStrategy, ExactSplitStrategy, and PercentageSplitStrategy provide specific implementations. The Expense class is configured with one of these strategies at runtime, allowing the splitting logic to be selected and changed dynamically without altering the Expense class itself.
The Expense object has a complex construction process, requiring different sets of parameters depending on the chosen split strategy. The Expense.ExpenseBuilder class simplifies this by providing a fluent, step-by-step interface for creating an Expense instance. This improves code readability and makes the Expense object's state immutable after construction.
The SplitwiseService also acts as a Facade. It provides a simple, unified interface (createExpense, settleUp, simplifyGroupDebts) to the client. This hides the underlying complexity of interactions between User, BalanceSheet, Expense, and Split objects. The client code only needs to interact with the Facade, making the system easier to use and decoupling the client from the internal implementation.
The SplitwiseService is implemented as a Singleton. This ensures that there is only one instance of the service throughout the application, providing a single, global point of access and control for managing all users, groups, and expenses. This prevents state inconsistencies that could arise from multiple service instances.
UserRepresents a system user.
1class User:
2 def __init__(self, name: str, email: str):
3 self._id = str(uuid.uuid4())
4 self._name = name
5 self._email = email
6 self._balance_sheet = BalanceSheet(self)
7
8 def get_id(self) -> str:
9 return self._id
10
11 def get_name(self) -> str:
12 return self._name
13
14 def get_balance_sheet(self) -> 'BalanceSheet':
15 return self._balance_sheetEach user has an associated BalanceSheet that tracks net debts or credits with other users.
TransactionRepresents a simplified debt between two users, typically the result of group debt consolidation.
1class Transaction:
2 def __init__(self, from_user: User, to_user: User, amount: float):
3 self._from = from_user
4 self._to = to_user
5 self._amount = amount
6
7 def __str__(self) -> str:
8 return f"{self._from.get_name()} should pay {self._to.get_name()} ${self._amount:.2f}"SplitRepresents how much a particular user owes in a specific expense.
1class Split:
2 def __init__(self, user: User, amount: float):
3 self._user = user
4 self._amount = amount
5
6 def get_user(self) -> User:
7 return self._user
8
9 def get_amount(self) -> float:
10 return self._amountGroupDefines a group context (e.g., "Friends Trip") in which expenses can be shared and simplified.
1class Group:
2 def __init__(self, name: str, members: List[User]):
3 self._id = str(uuid.uuid4())
4 self._name = name
5 self._members = members
6
7 def get_id(self) -> str:
8 return self._id
9
10 def get_name(self) -> str:
11 return self._name
12
13 def get_members(self) -> List[User]:
14 return self._members.copy()Expense and BuilderRepresents an expense paid by one user and shared among others.
Creating an Expense can be complex, as it requires different parameters depending on the split type. The Builder pattern provides a flexible and readable way to construct Expense objects.
1class Expense:
2 def __init__(self, builder: 'ExpenseBuilder'):
3 self._id = builder._id
4 self._description = builder._description
5 self._amount = builder._amount
6 self._paid_by = builder._paid_by
7 self._timestamp = datetime.now()
8
9 # Use the strategy to calculate splits
10 self._splits = builder._split_strategy.calculate_splits(
11 builder._amount, builder._paid_by, builder._participants, builder._split_values
12 )
13
14 def get_id(self) -> str:
15 return self._id
16
17 def get_description(self) -> str:
18 return self._description
19
20 def get_amount(self) -> float:
21 return self._amount
22
23 def get_paid_by(self) -> User:
24 return self._paid_by
25
26 def get_splits(self) -> List[Split]:
27 return self._splits
28
29 class ExpenseBuilder:
30 def __init__(self):
31 self._id: Optional[str] = None
32 self._description: Optional[str] = None
33 self._amount: Optional[float] = None
34 self._paid_by: Optional[User] = None
35 self._participants: Optional[List[User]] = None
36 self._split_strategy: Optional[SplitStrategy] = None
37 self._split_values: Optional[List[float]] = None
38
39 def set_id(self, expense_id: str) -> 'Expense.ExpenseBuilder':
40 self._id = expense_id
41 return self
42
43 def set_description(self, description: str) -> 'Expense.ExpenseBuilder':
44 self._description = description
45 return self
46
47 def set_amount(self, amount: float) -> 'Expense.ExpenseBuilder':
48 self._amount = amount
49 return self
50
51 def set_paid_by(self, paid_by: User) -> 'Expense.ExpenseBuilder':
52 self._paid_by = paid_by
53 return self
54
55 def set_participants(self, participants: List[User]) -> 'Expense.ExpenseBuilder':
56 self._participants = participants
57 return self
58
59 def set_split_strategy(self, split_strategy: SplitStrategy) -> 'Expense.ExpenseBuilder':
60 self._split_strategy = split_strategy
61 return self
62
63 def set_split_values(self, split_values: List[float]) -> 'Expense.ExpenseBuilder':
64 self._split_values = split_values
65 return self
66
67 def build(self) -> 'Expense':
68 if self._split_strategy is None:
69 raise ValueError("Split strategy is required.")
70 return Expense(self)Splits are calculated using a pluggable SplitStrategy.
BalanceSheetTracks a user's financial relationship with others.
1class BalanceSheet:
2 def __init__(self, owner: User):
3 self._owner = owner
4 self._balances: Dict[User, float] = {}
5 self._lock = threading.Lock()
6
7 def get_balances(self) -> Dict[User, float]:
8 return self._balances
9
10 def adjust_balance(self, other_user: User, amount: float):
11 with self._lock:
12 if self._owner == other_user:
13 return # Cannot owe yourself
14
15 if other_user in self._balances:
16 self._balances[other_user] += amount
17 else:
18 self._balances[other_user] = amount
19
20 def show_balances(self):
21 print(f"--- Balance Sheet for {self._owner.get_name()} ---")
22 if not self._balances:
23 print("All settled up!")
24 return
25
26 total_owed_to_me = 0
27 total_i_owe = 0
28
29 for other_user, amount in self._balances.items():
30 if amount > 0.01:
31 print(f"{other_user.get_name()} owes {self._owner.get_name()} ${amount:.2f}")
32 total_owed_to_me += amount
33 elif amount < -0.01:
34 print(f"{self._owner.get_name()} owes {other_user.get_name()} ${-amount:.2f}")
35 total_i_owe += (-amount)
36
37 print(f"Total Owed to {self._owner.get_name()}: ${total_owed_to_me:.2f}")
38 print(f"Total {self._owner.get_name()} Owes: ${total_i_owe:.2f}")
39 print("---------------------------------")SplitStrategy and ImplementationsTo handle different ways of splitting an expense (equally, by exact amounts, by percentage), we use the Strategy pattern.
1class SplitStrategy(ABC):
2 @abstractmethod
3 def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
4 pass
5
6class EqualSplitStrategy(SplitStrategy):
7 def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
8 splits = []
9 amount_per_person = total_amount / len(participants)
10 for participant in participants:
11 splits.append(Split(participant, amount_per_person))
12 return splits
13
14class ExactSplitStrategy(SplitStrategy):
15 def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
16 if len(participants) != len(split_values):
17 raise ValueError("Number of participants and split values must match.")
18 if abs(sum(split_values) - total_amount) > 0.01:
19 raise ValueError("Sum of exact amounts must equal the total expense amount.")
20
21 splits = []
22 for i in range(len(participants)):
23 splits.append(Split(participants[i], split_values[i]))
24 return splits
25
26class PercentageSplitStrategy(SplitStrategy):
27 def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
28 if len(participants) != len(split_values):
29 raise ValueError("Number of participants and split values must match.")
30 if abs(sum(split_values) - 100.0) > 0.01:
31 raise ValueError("Sum of percentages must be 100.")
32
33 splits = []
34 for i in range(len(participants)):
35 amount = (total_amount * split_values[i]) / 100.0
36 splits.append(Split(participants[i], amount))
37 return splitsSplitwiseService (Singleton)This class acts as a Singleton and a Facade, providing a single, simplified entry point for all application functionalities.
1class SplitwiseService:
2 _instance = None
3 _lock = threading.Lock()
4
5 def __new__(cls):
6 if cls._instance is None:
7 with cls._lock:
8 if cls._instance is None:
9 cls._instance = super().__new__(cls)
10 cls._instance._initialized = False
11 return cls._instance
12
13 def __init__(self):
14 if not self._initialized:
15 self._users: Dict[str, User] = {}
16 self._groups: Dict[str, Group] = {}
17 self._initialized = True
18
19 @classmethod
20 def get_instance(cls):
21 return cls()
22
23 def add_user(self, name: str, email: str) -> User:
24 user = User(name, email)
25 self._users[user.get_id()] = user
26 return user
27
28 def add_group(self, name: str, members: List[User]) -> Group:
29 group = Group(name, members)
30 self._groups[group.get_id()] = group
31 return group
32
33 def get_user(self, user_id: str) -> Optional[User]:
34 return self._users.get(user_id)
35
36 def get_group(self, group_id: str) -> Optional[Group]:
37 return self._groups.get(group_id)
38
39 def create_expense(self, builder: Expense.ExpenseBuilder):
40 with self._lock:
41 expense = builder.build()
42 paid_by = expense.get_paid_by()
43
44 for split in expense.get_splits():
45 participant = split.get_user()
46 amount = split.get_amount()
47
48 if paid_by != participant:
49 paid_by.get_balance_sheet().adjust_balance(participant, amount)
50 participant.get_balance_sheet().adjust_balance(paid_by, -amount)
51
52 print(f"Expense '{expense.get_description()}' of amount {expense.get_amount()} created.")
53
54 def settle_up(self, payer_id: str, payee_id: str, amount: float):
55 with self._lock:
56 payer = self._users[payer_id]
57 payee = self._users[payee_id]
58 print(f"{payer.get_name()} is settling up {amount} with {payee.get_name()}")
59
60 # Settlement is like a reverse expense. payer owes less to payee.
61 payee.get_balance_sheet().adjust_balance(payer, -amount)
62 payer.get_balance_sheet().adjust_balance(payee, amount)
63
64 def show_balance_sheet(self, user_id: str):
65 user = self._users[user_id]
66 user.get_balance_sheet().show_balances()
67
68 def simplify_group_debts(self, group_id: str) -> List[Transaction]:
69 group = self._groups.get(group_id)
70 if group is None:
71 raise ValueError("Group not found")
72
73 # Calculate net balance for each member within the group context
74 net_balances = {}
75 for member in group.get_members():
76 balance = 0
77 for other_user, amount in member.get_balance_sheet().get_balances().items():
78 # Consider only balances with other group members
79 if other_user in group.get_members():
80 balance += amount
81 net_balances[member] = balance
82
83 # Separate into creditors and debtors
84 creditors = [(user, balance) for user, balance in net_balances.items() if balance > 0]
85 debtors = [(user, balance) for user, balance in net_balances.items() if balance < 0]
86
87 creditors.sort(key=lambda x: x[1], reverse=True)
88 debtors.sort(key=lambda x: x[1])
89
90 transactions = []
91 i = j = 0
92
93 while i < len(creditors) and j < len(debtors):
94 creditor_user, creditor_amount = creditors[i]
95 debtor_user, debtor_amount = debtors[j]
96
97 amount_to_settle = min(creditor_amount, -debtor_amount)
98 transactions.append(Transaction(debtor_user, creditor_user, amount_to_settle))
99
100 creditors[i] = (creditor_user, creditor_amount - amount_to_settle)
101 debtors[j] = (debtor_user, debtor_amount + amount_to_settle)
102
103 if abs(creditors[i][1]) < 0.01:
104 i += 1
105 if abs(debtors[j][1]) < 0.01:
106 j += 1
107
108 return transactionsSplitwiseDemoThis driver class simulates real-world usage of the service, demonstrating all the key features.
1class SplitwiseDemo:
2 @staticmethod
3 def main():
4 # 1. Setup the service
5 service = SplitwiseService.get_instance()
6
7 # 2. Create users and groups
8 alice = service.add_user("Alice", "[email protected]")
9 bob = service.add_user("Bob", "[email protected]")
10 charlie = service.add_user("Charlie", "[email protected]")
11 david = service.add_user("David", "[email protected]")
12
13 friends_group = service.add_group("Friends Trip", [alice, bob, charlie, david])
14
15 print("--- System Setup Complete ---\n")
16
17 # 3. Use Case 1: Equal Split
18 print("--- Use Case 1: Equal Split ---")
19 service.create_expense(Expense.ExpenseBuilder()
20 .set_description("Dinner")
21 .set_amount(1000)
22 .set_paid_by(alice)
23 .set_participants([alice, bob, charlie, david])
24 .set_split_strategy(EqualSplitStrategy()))
25
26 service.show_balance_sheet(alice.get_id())
27 service.show_balance_sheet(bob.get_id())
28 print()
29
30 # 4. Use Case 2: Exact Split
31 print("--- Use Case 2: Exact Split ---")
32 service.create_expense(Expense.ExpenseBuilder()
33 .set_description("Movie Tickets")
34 .set_amount(370)
35 .set_paid_by(alice)
36 .set_participants([bob, charlie])
37 .set_split_strategy(ExactSplitStrategy())
38 .set_split_values([120.0, 250.0]))
39
40 service.show_balance_sheet(alice.get_id())
41 service.show_balance_sheet(bob.get_id())
42 print()
43
44 # 5. Use Case 3: Percentage Split
45 print("--- Use Case 3: Percentage Split ---")
46 service.create_expense(Expense.ExpenseBuilder()
47 .set_description("Groceries")
48 .set_amount(500)
49 .set_paid_by(david)
50 .set_participants([alice, bob, charlie])
51 .set_split_strategy(PercentageSplitStrategy())
52 .set_split_values([40.0, 30.0, 30.0])) # 40%, 30%, 30%
53
54 print("--- Balances After All Expenses ---")
55 service.show_balance_sheet(alice.get_id())
56 service.show_balance_sheet(bob.get_id())
57 service.show_balance_sheet(charlie.get_id())
58 service.show_balance_sheet(david.get_id())
59 print()
60
61 # 6. Use Case 4: Simplify Group Debts
62 print("--- Use Case 4: Simplify Group Debts for 'Friends Trip' ---")
63 simplified_debts = service.simplify_group_debts(friends_group.get_id())
64 if not simplified_debts:
65 print("All debts are settled within the group!")
66 else:
67 for debt in simplified_debts:
68 print(debt)
69 print()
70
71 service.show_balance_sheet(bob.get_id())
72
73 # 7. Use Case 5: Partial Settlement
74 print("--- Use Case 5: Partial Settlement ---")
75 # From the simplified debts, we see Bob should pay Alice. Let's say Bob pays 100.
76 service.settle_up(bob.get_id(), alice.get_id(), 100)
77
78 print("--- Balances After Partial Settlement ---")
79 service.show_balance_sheet(alice.get_id())
80 service.show_balance_sheet(bob.get_id())
81
82if __name__ == "__main__":
83 SplitwiseDemo.main()Which entity in a Splitwise-like system is responsible for keeping track of what each user owes and is owed?
No comments yet. Be the first to comment!